0%

用 C# .NET Core 爬取千張大戶資訊-股權分散表 | 用程式打造選股策略(5)

前言

今天要來爬的是股權分散表,用來統計大股東的持有比例,
網路上其實也有一些不錯的資源,像是: 神秘金字塔,針對400張、1000張以上的給出持股比例。

但這邊的目標是能做出「自動通知」或是「篩選」哪些股票是大戶持股正在增加工具,為此目標還是必須得將資料爬下來。那馬上開始吧!

觀察網站

我們可以從 台灣集中保管結算所

資料查詢 > 集保戶股權分散表

這裡可以查詢我們要的資訊。

不過這次我打算從 集保戶股權分散表查詢 來爬取,
這應該是比較舊的網站,不過資料是一樣的

進入網站,首先我們需要先知道可以爬取的日期,從F12觀察工具可以看到如下:

這個就是我們可以爬取的所有日期資訊,是JSON格式,處理起來還是比較容易的!

接下來是資料部分,查詢0050,觀察Request

這裡可以得到我們發送請求的URL、以及Form-Data
這樣就可以開始程式的部分了!

爬取股權分散表資訊

爬取HTML

首先先爬取目前可以用的日期,程式如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public async Task<IEnumerable<string>> GetDateStringListByTDCCAsync()
{
using (var client = _clientFactory.CreateClient())
{
var response = await client.PostAsync(
"https://www.tdcc.com.tw/smWeb/QryStockAjax.do",
new FormUrlEncodedContent(
new [] {
new KeyValuePair<string,string>("REQ_OPR","qrySelScaDates")
}
));
var result = await response.Content.ReadAsStringAsync();
if(response.StatusCode != System.Net.HttpStatusCode.OK)
throw new PlatformNotSupportedException($"目前無法爬取集保戶股權日期資料...,{response.StatusCode}{result}");
return JsonSerializer.Deserialize<List<string>>(result);
}
}

從剛剛查詢0050的Request得知,我們可以用股票代號、日期,來爬取整份HTML資料,程式如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public async Task<string> GetStockHolderHtmlByTDCCAsync(string stock_id, string dateString)
{
using (var client = _clientFactory.CreateClient())
{
var response = await client.PostAsync(
"https://www.tdcc.com.tw/smWeb/QryStockAjax.do",
new FormUrlEncodedContent(
new [] {
new KeyValuePair<string,string>("scaDates", dateString),
new KeyValuePair<string,string>("scaDate", dateString),
new KeyValuePair<string,string>("SqlMethod", "StockNo"),
new KeyValuePair<string,string>("StockNo", stock_id),
new KeyValuePair<string,string>("radioStockNo", stock_id),
new KeyValuePair<string,string>("StockName", ""),
new KeyValuePair<string,string>("REQ_OPR", "SELECT"),
new KeyValuePair<string,string>("clkStockNo", stock_id),
new KeyValuePair<string,string>("clkStockName", "")
//scaDates=20190607&scaDate=20190607&SqlMethod=StockNo&StockNo=0050&radioStockNo=0050&StockName=&REQ_OPR=SELECT&clkStockNo=0050&clkStockName=
}
));
var result = await response.Content.ReadAsStringAsync();
if(response.StatusCode != System.Net.HttpStatusCode.OK)
throw new PlatformNotSupportedException($"目前無法爬取集保戶股權資料...,{response.StatusCode}{result}");
return result;
}
}

這裡改用 IHttpClientFactory 來取得 HttpClient,主要原因是: 每個要求具現化 HttpClient 類別將會在負載過重時耗盡可用的通訊端數目
想要了解更多可以參考:
使用 IHttpClientFactory 實現彈性 HTTP 要求
使用的 HttpClient 錯誤的博客文章,它破壞了您的軟體

分析HTML

接下來要分析一下HTML,直接從Chrome F12工具中觀察,
可以發現回傳的資料是包在 class="mt"table 裡面

不過整個頁面中其實有兩個 class="mt"table,我們要的資料是第二個
一樣用 HtmlAgilityPack 這個套件來做解析,安裝的部分可以回去看看這篇 => 用 C# .NET Core 爬取每季財報

首先先建立儲存資料庫的 Model

1
2
3
4
5
6
7
8
9
10
11
12
[Table("StockHolder")]
public class StockHolder
{
[ExplicitKey]
public string stock_id { get; set; }
[ExplicitKey]
public string date_string { get; set; }
[ExplicitKey]
public string holder_level { get; set; }
public int people_count { get; set; }
public string stock_holder_count { get; set; }
}

接下來是解析HTML的部分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
public IEnumerable<StockHolder> ParseStockHolderHtml(string html, string stock_id, string dateString)
{
HtmlDocument doc = new HtmlDocument();
doc.LoadHtml(html);
var tableNodes = doc.DocumentNode.SelectNodes("//table[@class=\"mt\"]");
var trNodes = tableNodes[1].SelectNodes("./tbody/tr"); //class = mt 的第二筆
Dictionary<string, int> headerDictionary = new Dictionary<string, int>();
List<StockHolder> stockHolderList = new List<StockHolder>();
for(int trIndex=0; trIndex < trNodes.Count; trIndex++)
{
if(trIndex == 0) //header
{
var tdNodes = trNodes[trIndex].SelectNodes("./td");
for(int tdIndex=0; tdIndex < tdNodes.Count; tdIndex++)
{
headerDictionary.Add(tdNodes[tdIndex].InnerText.Replace(" ",""), tdIndex);
}
}
else //data
{
var tdNodes = trNodes[trIndex].SelectNodes("./td");

if(tdNodes[0].InnerText == "無此資料")
throw new Exception("無此資料");
string level;
if(tdNodes[headerDictionary["持股/單位數分級"]].InnerText.Replace(" ","") == "合計")
level = "total";
else
level = tdNodes[headerDictionary["序"]].InnerText;

if(level == "total" || Convert.ToInt32(level) <= 15) // 到15已經是千張資訊,但爬取時發現可能有些額外資訊,我們不需要
{
yield return new StockHolder() {
stock_id = stock_id,
date_string = dateString,
holder_level = level,
people_count = Convert.ToInt32(tdNodes[headerDictionary["人數"]].InnerText.Replace(",","")),
stock_holder_count = tdNodes[headerDictionary["股數/單位數"]].InnerText.Replace(",",""),
};
}
}
}
}

存入資料庫

最後是存入資料庫的部分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
public class StockHolderRepository
{
private readonly SqlConnection _conn;
private readonly ILogger<StockHolderRepository> _logger;
public StockHolderRepository(ILogger<StockHolderRepository> logger, SqlConnection conn)
{
_logger = logger;
_conn = conn;
}

public void Insert(IEnumerable<StockHolder> stockHolderList)
{
try
{
using (var scope = new TransactionScope())
{
foreach (var stockHolder in stockHolderList)
{
_conn.Insert(stockHolder);
}
scope.Complete();
}
}
catch (Exception ex)
{
_logger.LogError(ex.Message);
}
}

public bool IsExist(string stockId, string dateString)
{
return _conn.ExecuteScalar<bool>(
"select count(1) from StockHolder where stock_id=@stockId and date_string=@dateString",
new { stockId, dateString}
);
}
}

整理成排程

接下來整理成一個讓排成呼叫的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
public async Task ExecuteAsync(string stock_id, string dateString) 
{
try
{
var html = await GetStockHolderHtmlByTDCCAsync(stock_id, dateString);
var stockHolderList = ParseStockHolderHtml(html, stock_id, dateString);
_stockHolderRepository.Insert(stockHolderList);
}
catch(Exception ex)
{
_logger.LogWarning($"StockHolderClawer error\n{ex.Message}");
}
}

最後一樣是使用 Coravel 這個套件來做排程,想了解使用方式可以參考這篇 用 C# .NET Core 自動爬取台股每日股價

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
public class StockHolderClawerSchedule : IInvocable
{
private StockHolderClawer _stockHolderClawer;
private StockHolderRepository _stockHolderRepository;
private StockInfoRepository _stockInfoRepository;
private readonly ILogger<StockHolderClawerSchedule> _logger;

public StockHolderClawerSchedule (ILogger<StockHolderClawerSchedule> logger, StockHolderClawer stockHolderClawer, StockHolderRepository stockHolderRepository, StockInfoRepository stockInfoRepository)
{
_stockHolderRepository = stockHolderRepository;
_stockInfoRepository = stockInfoRepository;
_stockHolderClawer = stockHolderClawer;
_logger = logger;
}
public async Task Invoke()
{
_logger.LogInformation("StockHolderClawerSchedule Start");

var stockIdList = _stockInfoRepository.GetAllStockIdByType("上市"); // 取得所有股票代號,這裡省略囉!
var dateStringList = (await _stockHolderClawer.GetDateStringListByTDCCAsync()).ToList();

foreach(var dateString in dateStringList)
{
foreach(var stockId in stockIdList)
{
if(!_stockHolderRepository.IsExist(stockId, dateString))
{
_logger.LogInformation($"StockHolderClawer Execute: {stockId}, {dateString}");
await _stockHolderClawer.ExecuteAsync(stockId, dateString);
Thread.Sleep(6000);
}
}
}
}
}

最後註冊排程跑的時間:

1
2
3
scheduler
.Schedule<StockHolderClawerSchedule>()
.Cron("41 17 * * 5"); //分鐘 小時 日 月 星期 (UTC時間)

搞定收工!

心得

其實集保所每週會公布一份總表,每週五可以改從總表抓取資料,應該會快很多!
可惜的是沒有歷史資訊的總表可以查,所以才採用一檔一檔爬的方式!
這樣確實得花非常多的時間,目前還沒有找到比較好的解決方法。
但也因此發現了HttpClient的小問題,也算是不錯的收穫吧!

↓↓↓ 如果喜歡我的文章,可以幫我按個Like! ↓↓↓
>> 或者,請我喝杯咖啡,這樣我會更有動力唷! <<<
街口支付

街口支付

街口帳號: 901061546

歡迎關注我的其它發布渠道